33 Tween.js 动画库集成与基础动画开发
Tween.js 动画库集成与基础动画开发
关联:索引
要解决的问题
- 工业 3D 场景里的“运动”到底是什么:是每帧手动改
position/rotation,还是用动画系统描述“从 A 到 B 的过程”? - 设备运动看起来“不真实”的根因是什么:速度曲线不对(缓动函数)、时间参数不对(时长/延迟)、还是渲染循环更新方式不对?
- 动画与渲染循环如何正确协作:为什么 Tween.js 必须在
requestAnimationFrame中update,而不推荐setInterval驱动动画? - 多个动画如何组织:先移动再旋转(序列),还是移动同时旋转(并行)?工程里如何做到“可读、可维护、可复用”?
- 工业场景的最低标准是什么:AGV 平移轨迹、机械臂关节简单旋转、运动节奏一致、帧率稳定(不卡顿、无明显跳变)?
本讲定位(与前置衔接,避免重复)
- 已具备:Three.js 最小渲染闭环(Scene/Camera/Renderer/Mesh)、在 Vue3 + TS 中挂载与卸载 dispose、理解
position/rotation/scale的意义(参考:Three.js 入门、几何体与材质、OrbitControls、Raycaster)。 - 本讲新增:Tween.js 动画核心机制(Tween + 时间 + 缓动);基础动画(位移/旋转/缩放);动画参数调试(时长/缓动/延迟);动画序列与并行组织;工业场景的“运动还原”思维;动画流畅性测试要点。
- 本讲不展开:骨骼动画(SkinnedMesh)、关键帧动画(AnimationMixer)、复杂曲线路径规划与速度规划、物理引擎驱动(后续按项目需求扩展)。
章节内容(本讲核心)
- Tween.js 动画库的引入与核心原理(“时间驱动插值”)
- 基础动画(位置移动、旋转、缩放)的创建与配置
- 动画参数(时长、缓动函数、延迟时间)的调试与效果对比
- 动画序列与并行动画的实现(链式/Promise 化组织)
- 工业场景基础动画(设备平移、简单旋转)开发
- 动画流畅性测试要点(帧率、抖动、资源释放、更新闭环)
环境与先修(默认沿用 Three.js 工程)
先修要求:
- 已有 Vue3 + Vite + TypeScript 工程,并已安装 Three.js。
- 已能渲染基础场景(至少能看到一个 Mesh),并具备组件卸载 dispose 的意识。
本讲新增依赖(Tween.js):
npm i @tweenjs/tween.js
解释:
@tweenjs/tween.js:轻量级补间动画库(Tweening)。它不负责渲染,只负责计算“随时间变化的数值”,你需要在渲染循环里把这些数值写回 Three.js 对象。- 如你是在“新工程/新电脑”第一次做本讲实验:通常还需要确保已安装 Three.js 与类型定义(有些环境下
three的类型不会被正确识别,构建会报 TS7016):
npm i three
npm i -D @types/three
- 运动不是“瞬移到目标”,而是“在一段时间内连续变化”。
- 连续变化的关键不是每帧改多少,而是“速度曲线”像不像(缓动函数决定了“先快后慢/先慢后快/匀速”等感觉)。
- 运动要可信:节奏一致、方向正确、帧率稳定、无明显抖动与跳变。
Tween.js 可以理解成“时间驱动插值器”:
- 你提供:起点数据、终点数据、时长(duration)、缓动(easing)、可选延迟(delay)。
- Tween.js 负责:给出任意时刻
t对应的“中间值”。 - 你负责:把“中间值”写回到 Three.js 对象的
position/rotation/scale(或其他你需要驱动的状态)。
最重要的结论(背下来就能排错):
- Tween.js 不会自动运行。你必须在渲染循环中持续调用
update(time),动画才会推进。 - 推荐把
update放进requestAnimationFrame的循环里,并使用同一份时间基准(time参数或performance.now())。
补充一条“工业化口径”(避免只追求“丝滑”):
- 工业设备运动通常要对齐真实节拍:关注的是“节奏一致、可复现、可解释”,而不是“越快越好看”。
- 因此动画参数建议从“运动指标”推导:距离/角度 → 目标速度(m/s、deg/s)→ 时长(ms),缓动函数用来模拟启停与负载感,而不是随意试出来。
目标:
- 在 Vue3 + TS + Three.js 组件中集成 Tween.js。
- 做一个 AGV(用 BoxGeometry 代替即可)从点 A 平移到点 B 的动画。
建议落地文件路径(班级统一口径,便于排错):
src/
└─ components/
└─ TweenBasicLab.vue
1) 可复制运行:Tween.js 最小闭环组件(Vue3 + TS)
src/components/TweenBasicLab.vue:
<template>
<!-- Three.js 的 canvas 会被插入到这个容器里 -->
<div ref="containerRef" class="three-container"></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { Easing, Group, Tween } from '@tweenjs/tween.js';
// 1) 容器 DOM 引用:用于挂载 renderer.domElement(canvas)
const containerRef = ref<HTMLDivElement | null>(null);
// 2) Three.js 核心对象:在 mounted 时创建,在 beforeUnmount 时释放
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
// 3) 生命周期相关句柄:用于停止渲染循环与尺寸监听,避免“越切越卡”
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
// 4) Tween.js 的“动作容器”:统一管理本组件里所有 Tween
// 好处:只需要一个 tweenGroup.update(time) 就能推进全部动画;
// 未来也可以做到:暂停/恢复/清空(removeAll)
const tweenGroup = new Group();
// 5) 场景中的对象:这里用一个盒子代表 AGV 小车
let agv: THREE.Mesh | null = null;
function resize() {
const container = containerRef.value;
if (!container || !renderer || !camera) return;
// 容器大小(注意:容器高度为 0 时,Three.js 会表现为“黑屏/看不到”)
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
// 像素比:上限 2,避免高 DPI 设备渲染成本过高导致掉帧
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 让 canvas 尺寸与容器一致(包含 style 尺寸),避免画布仍停留在默认 300×150 导致“右侧看起来是空的”
renderer.setSize(width, height);
// 相机宽高比必须跟随容器变化,否则画面会被拉伸
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
// 渲染循环:浏览器每帧调用一次(通常 ~60fps,实际取决于设备与负载)
function animate(time: number) {
if (!renderer || !scene || !camera) return;
// 关键:推进 Tween(用 rAF 的 time 作为统一时间基准,避免与渲染不同步)
tweenGroup.update(time);
// 关键:渲染一帧
renderer.render(scene, camera);
// 下一帧继续
rafId = requestAnimationFrame(animate);
}
// 业务动作:让 AGV 从当前位置移动到目标点(只演示一次)
function moveAgvOnce() {
if (!agv) return;
// state:Tween 驱动的数据对象(推荐做法)
// - Tween.js 会不断修改 state.x/state.z
// - 我们在 onUpdate 里把 state 写回到 Three.js 对象
// - 这样可以避免 Tween 直接操作 Three.js 对象导致的耦合与副作用
const state = { x: agv.position.x, z: agv.position.z };
// target:目标位置(这里使用 XZ 平面移动,Y 由我们固定到 0.1)
const target = { x: 2.5, z: 1.2 };
// 创建一个 Tween(把它挂到 tweenGroup 里,便于统一管理)
new Tween(state, tweenGroup)
// to(target, durationMs):从当前 state 补间到 target,持续 1500ms
.to(target, 1500)
// easing:速度曲线(先加速后减速,工业场景更像电机启停)
.easing(Easing.Quadratic.InOut)
// delay:延迟 200ms 开始(常用于“停稳/等待上一动作结束/启动缓冲”)
.delay(200)
// onUpdate:每次 state 更新时,把 state 写回 Three.js 对象
.onUpdate(() => {
// 这里把 Y 固定为 0.1,让 AGV “贴地但不穿透网格”
agv?.position.set(state.x, 0.1, state.z);
})
// start:启动(注意:Tween 不会自动运行,必须 start 且在 rAF 中 update)
.start();
}
onMounted(() => {
const container = containerRef.value;
if (!container) throw new Error('Three container not found');
// ---------- Three.js:创建场景 ----------
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
// ---------- Three.js:创建渲染器 ----------
renderer = new THREE.WebGLRenderer({ antialias: true });
// 颜色空间:保证材质颜色在 sRGB 空间下更符合预期(与现代 Three.js 口径一致)
renderer.outputColorSpace = THREE.SRGBColorSpace;
// 把 canvas 插入到容器
container.appendChild(renderer.domElement);
// ---------- Three.js:创建相机 ----------
// aspect 初始给 1,随后立刻调用 resize() 修正
camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
camera.position.set(4, 3, 6);
camera.lookAt(0, 0, 0);
// ---------- 辅助物:帮助校准坐标系与尺度 ----------
scene.add(new THREE.AxesHelper(2));
scene.add(new THREE.GridHelper(10, 10));
// ---------- 场景对象:AGV ----------
const agvGeo = new THREE.BoxGeometry(0.8, 0.2, 0.6);
const agvMat = new THREE.MeshStandardMaterial({ color: 0x22c55e, roughness: 0.6, metalness: 0.1 });
agv = new THREE.Mesh(agvGeo, agvMat);
agv.position.set(0, 0.1, 0);
scene.add(agv);
// ---------- 光照:StandardMaterial 需要光才能“有质感” ----------
const ambient = new THREE.AmbientLight(0xffffff, 0.7);
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(3, 5, 2);
scene.add(ambient, dir);
// ---------- 工程化:自适应尺寸 ----------
resize();
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
// ---------- 启动渲染循环 + 启动动画 ----------
rafId = requestAnimationFrame(animate);
moveAgvOnce();
});
onBeforeUnmount(() => {
// 1) 停止 rAF 渲染循环
if (rafId !== null) cancelAnimationFrame(rafId);
// 2) 停止尺寸监听
if (resizeObserver) resizeObserver.disconnect();
// 3) 从场景移除对象(避免引用残留)
if (scene && agv) scene.remove(agv);
// 4) 释放几何体与材质(释放 GPU 资源)
const geometry = agv?.geometry;
if (geometry && 'dispose' in geometry) geometry.dispose();
const material = agv?.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material?.dispose();
// 5) 释放渲染器
renderer?.dispose();
// 6) 移除 canvas(避免路由切换后 DOM 越堆越多)
if (renderer?.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
// 7) 断开引用(帮助 GC,避免误用已释放对象)
agv = null;
camera = null;
scene = null;
renderer = null;
resizeObserver = null;
rafId = null;
});
</script>
<style scoped>
.three-container {
width: 100%;
/* 保证容器有高度,否则 clientHeight=0 导致“看似渲染失败” */
height: 100vh;
overflow: hidden;
}
</style>
解释(关键点逐条对照):
- 依赖引入:
import { Easing, Group, Tween } from '@tweenjs/tween.js':使用 Tween.js 的三件套(缓动、组、Tween 实例)。const tweenGroup = new Group():把本组件产生的 Tween 统一交给同一个组管理,方便集中update、也便于未来暂停/恢复/清理。- 渲染循环协作:
function animate(time) { tweenGroup.update(time); renderer.render(...); requestAnimationFrame(...) }time来自requestAnimationFrame,它是浏览器提供的高精度时间戳;把它传给update,Tween 才能按真实时间推进。- 位移动画(AGV 平移):
state是“被 Tween 驱动的数据对象”,Tween 只会改state,不会直接改 Three.js 对象。onUpdate才是“写回 Three.js 的落点”:把state.x/state.z写入agv.position。- 参数调试入口:
.to(target, 1500):时长 1500ms;改它能直观看到“快/慢”的变化。.easing(Easing.Quadratic.InOut):速度曲线(先加速后减速);改它能直观看到“像不像设备运动”。.delay(200):延迟 200ms;用于做“启动缓冲/等待上一动作结束”的节奏控制。
自检清单(本段代码运行不起来优先按顺序排):
- 容器高度是否为 0(
.three-container { height: 100vh; }是否存在)。 animate是否启动(是否执行requestAnimationFrame(animate))。tweenGroup.update(time)是否在render之前被调用。moveAgvOnce()是否被调用、agv是否非空。
在工业场景里,最常见的“基础动画三件套”对应 Three.js 的三大变换:
- 位置:
object.position(AGV 平移/设备移动到工位) - 旋转:
object.rotation或object.quaternion(关节旋转/设备转向) - 缩放:
object.scale(强调/聚焦、占位模型的简单动态)
1) 旋转动画(示例:设备绕 Y 轴旋转)
function rotateOnce(object: THREE.Object3D) {
// 旋转状态:Tween 驱动的数值对象(单位:弧度)
const state = { y: object.rotation.y };
// 目标:绕 Y 轴旋转 90°
const target = { y: state.y + Math.PI / 2 };
// 注意:object.rotation 是欧拉角(Euler),这里直接写 rotation.y 便于理解;
// 工业中如果涉及复杂姿态/组合旋转,优先考虑 quaternion,避免万向节锁
new Tween(state, tweenGroup)
// 800ms:相对“干脆”的转动,适合按钮触发的简单旋转
.to(target, 800)
// Cubic.InOut:启停更柔和,更像“电机带负载”
.easing(Easing.Cubic.InOut)
.onUpdate(() => {
// 每次更新都把 state 写回到对象
object.rotation.y = state.y;
})
.start();
}
解释:
state.y用弧度(Three.js 的rotation单位是弧度,不是角度)。Cubic.InOut相比Quadratic.InOut通常更“柔”,适合模拟电机带负载的启停感。
2) 缩放动画(示例:设备被选中时轻微“呼吸”)
function pulseScale(object: THREE.Object3D) {
// s:统一缩放因子(1 表示原大小)
const state = { s: 1 };
new Tween(state, tweenGroup)
// 先放大到 1.08(轻微“呼吸”,不要太夸张,否则像 UI 特效而不像工业提示)
.to({ s: 1.08 }, 300)
// Out:更快到达峰值,体现“被选中”的即时反馈
.easing(Easing.Quadratic.Out)
// yoyo:回弹(放大后再缩回)
.yoyo(true)
// repeat(1):只来回一次(放大一次 + 缩回一次)
.repeat(1)
.onUpdate(() => {
// setScalar:三轴等比缩放,避免模型比例被破坏
object.scale.setScalar(state.s);
})
.start();
}
解释:
yoyo(true) + repeat(1):先放大一次,再回到原始大小(一次“呼吸”)。setScalar:三轴等比缩放,避免设备比例被拉坏。
两种组织方式对应两类工业需求:
- 序列(Sequence):动作有先后关系。例:AGV 到位 → 停稳 → 机械臂开始动作。
- 并行(Parallel):动作同时发生但互不依赖。例:AGV 移动时,机械臂同时做小角度调整(或灯光闪烁等状态提示)。
工程建议(避免“回调地狱”):
-
把“一个动作”封装成一个函数,返回
Promise<void>。 -
序列:
await a(); await b(); -
并行:
await Promise.all([a(), b()]) -
动画要可控:必须能“防连点/取消上一轮/重置到可预测状态”,否则现场演示会出现叠加抖动、路径乱跳。
本项目工坊目标:
- 为 3D 场景中的 AGV 小车实现平移动画(指定路径)。
- 为机械臂关节实现简单旋转动画(指定角度)。
- 配置合适的缓动函数与时长,实现流畅的基础动画效果。
- 实现多个动画的并行或序列执行。
建议落地文件路径:
src/
└─ components/
└─ TweenIndustrialLab.vue
1) 可复制运行:工业基础动画组件(Vue3 + TS)
src/components/TweenIndustrialLab.vue:
<template>
<!-- 容器:Three.js canvas + HUD 叠加层 -->
<div ref="containerRef" class="three-container">
<div class="hud">
<div class="row">Tween.js 工业动画实验</div>
<div class="row">
<button class="btn" @click="runSequence">序列:AGV 走路径 → 关节旋转</button>
</div>
<div class="row">
<button class="btn" @click="runParallel">并行:AGV 走路径 + 关节旋转</button>
</div>
<!-- fps 是一个粗略指标:用于自检“动画是否明显掉帧” -->
<div class="row">FPS(估算):{{ fps }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { Easing, Group, Tween } from '@tweenjs/tween.js';
// DOM 容器引用:用于挂载 renderer.domElement
const containerRef = ref<HTMLDivElement | null>(null);
// FPS(估算):不追求精度,追求“快速发现异常”
const fps = ref(0);
// Three.js 核心对象(mounted 创建 / beforeUnmount 释放)
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
// 生命周期句柄(用于停止循环与监听)
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
// Tween 容器:统一管理所有 Tween,做到可控(清空/暂停/恢复)
const tweenGroup = new Group();
// 业务对象:AGV 与机械臂(用几何体代替)
let agv: THREE.Group | null = null;
let armBase: THREE.Group | null = null;
let joint1: THREE.Group | null = null;
// 记录 Mesh 引用:用于卸载时 dispose 几何体/材质,避免 GPU 资源累积
let agvBody: THREE.Mesh | null = null;
let baseMesh: THREE.Mesh | null = null;
let link1: THREE.Mesh | null = null;
// FPS 统计:用“半秒窗口”估算一次(避免每帧计算过重)
let frameCount = 0;
let lastFpsTime = 0;
// 工业化配置:把“运动逻辑参数”集中收口,避免散落在代码里难以统一调整
// - 速度/时长钳制:保证节拍可解释、可复现
// - easing:模拟启停与负载感(而不是追求花哨)
const motionConfig = {
// 单位约定:这里假设场景单位≈米(1 unit ≈ 1m)
unitIsMeter: true,
// AGV 目标速度(m/s):用它推导每段路径的时长
agvSpeedMps: 1.2,
// 单段移动时长的上下限(ms):防止太短“闪现”、太长“拖沓”
agvMinSegmentMs: 450,
agvMaxSegmentMs: 2600,
// 到达路点后的“停稳”时间(ms):模拟设备到位的停顿,减少漂移感
waypointDwellMs: 200,
// 平移缓动:更像电机启停
agvMoveEasing: Easing.Quadratic.InOut,
// 航向(yaw)缓动:更柔和,像行驶中转向
agvYawEasing: Easing.Cubic.InOut,
// 关节缓动:启停柔和,像关节伺服
jointYawEasing: Easing.Cubic.InOut,
} as const;
function resize() {
const container = containerRef.value;
if (!container || !renderer || !camera) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) return;
// 统一像素比上限,兼顾清晰度与性能
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function animate(time: number) {
if (!renderer || !scene || !camera) return;
// 1) 推进所有 Tween(time 来自 rAF,是统一时间基准)
tweenGroup.update(time);
// 2) 渲染
renderer.render(scene, camera);
// 3) 下一帧
rafId = requestAnimationFrame(animate);
// 4) FPS 估算:用于发现“掉帧/抖动”
frameCount += 1;
if (lastFpsTime === 0) lastFpsTime = time;
if (time - lastFpsTime >= 500) {
fps.value = Math.round((frameCount * 1000) / (time - lastFpsTime));
frameCount = 0;
lastFpsTime = time;
}
}
// 把“一段 Tween 动作”包装成 Promise:便于用 await 组织序列,用 Promise.all 组织并行
function tweenTo<T extends Record<string, number>>(
state: T,
target: Partial<T>,
options: { durationMs: number; delayMs?: number; easing?: (k: number) => number },
onUpdate: () => void,
) {
return new Promise<void>((resolve) => {
new Tween(state, tweenGroup)
.to(target as T, options.durationMs)
.easing(options.easing ?? Easing.Quadratic.InOut)
.delay(options.delayMs ?? 0)
.onUpdate(onUpdate)
.onComplete(() => resolve())
.start();
});
}
// 工具:把时长钳制到合理区间(避免过短/过长)
function clampMs(ms: number, min: number, max: number) {
return Math.min(max, Math.max(min, ms));
}
// 工业化关键:用“距离/速度”推导时长,而不是拍脑袋写死 1200ms
function computeMoveDurationMs(from: THREE.Vector3, to: THREE.Vector3) {
const dx = to.x - from.x;
const dz = to.z - from.z;
// 这里用 XZ 平面的欧氏距离(假设地面运动)
const distance = Math.hypot(dx, dz);
// 最小速度保护:避免速度=0 导致除零
const speed = Math.max(0.1, motionConfig.agvSpeedMps);
return clampMs(
(distance / speed) * 1000,
motionConfig.agvMinSegmentMs,
motionConfig.agvMaxSegmentMs,
);
}
// 由路径方向估算航向角(yaw)
// - 约定:AGV 的“前进方向”指向 +Z(与 GridHelper 的直觉一致)
// - Math.atan2(dx, dz) 的写法对应“朝向目标点”的 yaw
function computeYawRad(from: THREE.Vector3, to: THREE.Vector3) {
const dx = to.x - from.x;
const dz = to.z - from.z;
if (dx === 0 && dz === 0) return 0;
return Math.atan2(dx, dz);
}
// 角度归一化:让旋转走“最短路径”,避免跨过 ±π 时突然转一整圈
function shortestAngleTo(from: number, to: number) {
const delta = ((to - from + Math.PI) % (2 * Math.PI)) - Math.PI;
return from + delta;
}
// 等待:用 Tween 做一个“纯计时动作”,统一由 tweenGroup 管理
// - 这样序列里的“停稳”也能跟动画同一套时间基准推进
function waitMs(ms: number) {
const state = { t: 0 };
return tweenTo(state, { t: 1 }, { durationMs: ms, easing: Easing.Linear.None }, () => void 0);
}
// AGV 沿路径移动(分段执行)
async function moveAgvAlongPath(points: Array<THREE.Vector3>) {
if (!agv) return;
if (points.length < 2) return;
for (let i = 0; i < points.length - 1; i += 1) {
const from = points[i];
const to = points[i + 1];
// 1) 本段时长:由距离/速度推导(工业化关键)
const durationMs = computeMoveDurationMs(from, to);
// 2) 平移 state:Tween 驱动的数据对象
const state = { x: from.x, z: from.z };
// 3) 航向 state:从当前 yaw 出发,旋到“指向下一路点”的 yaw
const yawState = { y: agv.rotation.y };
const targetYaw = shortestAngleTo(yawState.y, computeYawRad(from, to));
// 平移与转向并行推进:更接近真实行驶(边走边对齐航向)
await Promise.all([
tweenTo(
state,
{ x: to.x, z: to.z },
{ durationMs, easing: motionConfig.agvMoveEasing },
() => {
// onUpdate:写回位置(Y=0 贴地)
agv?.position.set(state.x, 0, state.z);
},
),
tweenTo(
yawState,
{ y: targetYaw },
// 转向通常比位移稍快完成(0.7 倍时长并钳制),让“车头先对齐”
{ durationMs: clampMs(durationMs * 0.7, 300, 1200), easing: motionConfig.agvYawEasing },
() => {
if (!agv) return;
agv.rotation.y = yawState.y;
},
),
]);
// 到达路点后“停稳”:模拟设备到位,便于后续机械臂动作/状态切换
if (motionConfig.waypointDwellMs > 0 && i < points.length - 2) {
await waitMs(motionConfig.waypointDwellMs);
}
}
}
// 机械臂关节旋转(单关节示例)
async function rotateJointY(deg: number) {
if (!joint1) return;
// rotation.y 是弧度,输入 deg 先转弧度
const start = joint1.rotation.y;
const target = start + THREE.MathUtils.degToRad(deg);
const state = { y: start };
await tweenTo(
state,
{ y: target },
{ durationMs: 900, easing: motionConfig.jointYawEasing, delayMs: 150 },
() => {
if (!joint1) return;
joint1.rotation.y = state.y;
},
);
}
// 工业化:每次触发动作前,先清空上一轮 Tween,并重置到可预测状态
function resetPose() {
// 清空所有 Tween,防止按钮连点导致堆积/叠加
tweenGroup.removeAll();
agv?.position.set(0, 0, 0);
if (agv) agv.rotation.y = 0;
if (joint1) joint1.rotation.y = 0;
}
// 序列:先 AGV 走完路径,再机械臂旋转
async function runSequence() {
resetPose();
const path = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(2.5, 0, 0),
new THREE.Vector3(2.5, 0, 1.6),
new THREE.Vector3(0.8, 0, 1.6),
];
await moveAgvAlongPath(path);
await rotateJointY(60);
}
// 并行:AGV 走路径的同时,机械臂也旋转(互不等待)
async function runParallel() {
resetPose();
const path = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(2.2, 0, 0),
new THREE.Vector3(2.2, 0, 1.2),
new THREE.Vector3(0.5, 0, 1.2),
];
await Promise.all([moveAgvAlongPath(path), rotateJointY(60)]);
}
onMounted(() => {
const container = containerRef.value;
if (!container) throw new Error('Three container not found');
// ---------- 场景 ----------
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1220);
// ---------- 渲染器 ----------
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
// ---------- 相机 ----------
camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
camera.position.set(5, 4, 7);
camera.lookAt(1, 0, 0.5);
// ---------- 辅助物:坐标轴与网格 ----------
scene.add(new THREE.AxesHelper(2));
scene.add(new THREE.GridHelper(10, 10));
// ---------- 光照:让 StandardMaterial 正常显示 ----------
const ambient = new THREE.AmbientLight(0xffffff, 0.7);
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(3, 5, 2);
scene.add(ambient, dir);
// ---------- AGV:用 Group 作为根节点,便于未来扩展为“车体+轮子+传感器”层级 ----------
const agvGroup = new THREE.Group();
agvBody = new THREE.Mesh(
new THREE.BoxGeometry(0.8, 0.2, 0.6),
new THREE.MeshStandardMaterial({ color: 0x22c55e, roughness: 0.6, metalness: 0.1 }),
);
agvBody.position.set(0, 0.1, 0);
agvGroup.add(agvBody);
agv = agvGroup;
scene.add(agvGroup);
// ---------- 机械臂底座 ----------
const base = new THREE.Group();
base.position.set(0.5, 0, 1.2);
baseMesh = new THREE.Mesh(
new THREE.CylinderGeometry(0.25, 0.25, 0.15, 24),
new THREE.MeshStandardMaterial({ color: 0x93c5fd, roughness: 0.5, metalness: 0.2 }),
);
baseMesh.position.y = 0.075;
base.add(baseMesh);
armBase = base;
scene.add(base);
// ---------- 关节 1(绕 Y 轴旋转) ----------
// 关节用 Group 表示:旋转时只需要改 group.rotation
const j1 = new THREE.Group();
j1.position.set(0, 0.15, 0);
link1 = new THREE.Mesh(
new THREE.BoxGeometry(0.6, 0.08, 0.12),
new THREE.MeshStandardMaterial({ color: 0xfbbf24, roughness: 0.6, metalness: 0.1 }),
);
link1.position.set(0.3, 0.04, 0);
j1.add(link1);
base.add(j1);
joint1 = j1;
// ---------- 自适应 + 启动循环 ----------
resize();
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
rafId = requestAnimationFrame(animate);
});
onBeforeUnmount(() => {
// 1) 停止渲染循环与监听
if (rafId !== null) cancelAnimationFrame(rafId);
if (resizeObserver) resizeObserver.disconnect();
// 2) 清空 Tween(防止未完成的 Tween 引用对象导致泄漏)
tweenGroup.removeAll();
// 3) 从场景移除根节点(解除场景引用)
if (scene && agv) scene.remove(agv);
if (scene && armBase) scene.remove(armBase);
// 4) 释放几何体与材质(释放 GPU 资源)
const disposeMesh = (mesh: THREE.Mesh | null) => {
if (!mesh) return;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) material.forEach((m) => m.dispose());
else material?.dispose();
};
disposeMesh(link1);
disposeMesh(baseMesh);
disposeMesh(agvBody);
// 5) 释放渲染器并移除 canvas
renderer?.dispose();
if (renderer?.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
// 6) 断开引用,避免误用
link1 = null;
baseMesh = null;
agvBody = null;
joint1 = null;
armBase = null;
agv = null;
camera = null;
scene = null;
renderer = null;
resizeObserver = null;
rafId = null;
});
</script>
<style scoped>
.three-container {
width: 100%;
height: 100vh;
overflow: hidden;
position: relative;
}
.hud {
/* HUD 叠加层:用于触发动作与展示自检信息 */
position: absolute;
left: 12px;
top: 12px;
display: grid;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.65);
color: #e2e8f0;
font-size: 14px;
user-select: none;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.btn {
padding: 6px 10px;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 8px;
background: rgba(15, 23, 42, 0.7);
color: #e2e8f0;
cursor: pointer;
}
.btn:hover {
border-color: rgba(148, 163, 184, 0.7);
}
</style>
解释(把“序列/并行”工程化落地):
-
tweenTo(...)(核心封装): -
输入:
state + target + duration/easing/delay + onUpdate。 -
输出:
Promise<void>,方便用await组织序列,用Promise.all组织并行。 -
onComplete(() => resolve()):把 Tween 的完成时刻映射成 Promise 的完成时刻。 -
AGV 路径平移(序列):
-
moveAgvAlongPath(points)用for循环依次await每一段,天然就是“按段序列执行”。 -
工业化参数推导(让节拍可解释、可复现):
-
computeMoveDurationMs(from, to):按距离与目标速度(m/s)推导时长(ms),并用min/max做钳制,避免“距离很短却一闪而过/距离很长拖得太久”。 -
motionConfig.agvSpeedMps:优先从设备的“目标速度”定,而不是凭感觉写死1200ms。 -
航向对齐(让运动更像设备):
-
computeYawRad(from, to):根据路径方向估算 AGV 的朝向角(yaw)。 -
shortestAngleTo:确保转向走最短角度,避免跨过 ±π 时突然反向转一大圈。 -
位置与航向用
Promise.all并行推进:效果更接近“行驶中逐渐调整航向”。 -
机械臂关节旋转(单关节示例):
-
rotateJointY(deg)用degToRad做角度转弧度,避免“输入 60 结果转了一大圈”的新手坑。 -
Cubic.InOut:更柔和,适合机械关节启停。 -
并行与序列组合:
-
runSequence:先await moveAgvAlongPath再await rotateJointY。 -
runParallel:await Promise.all([moveAgvAlongPath, rotateJointY]),两个动作互不等待。 -
重复触发的工程处理:
-
resetPose()中的tweenGroup.removeAll():在启动新一轮动作前清空上一轮 Tween,避免按钮连点导致 Tween 堆积、路径叠加而出现“越来越奇怪”的运动。 -
FPS(估算) 的意义:
-
这是一个“自检仪表”,用于快速判断动画是否明显掉帧。
-
它不是精密测量,但能帮你发现:加了动画/加了交互后帧率突然变差。
-
节拍一致:同一路径多次触发,AGV 的总用时稳定(误差可控),不会时快时慢。
-
可解释:能说清时长从哪里来(距离/速度/钳制),缓动函数为什么选它(启停感/负载感)。
-
可控:按钮连点不会让路径叠加、不会“越跑越飞”;重置后状态可预测。
-
停稳感:到达路点后有短暂停顿(
waypointDwellMs),避免“连续拐点像漂移”。 -
流畅:在常见设备上 FPS 不明显抖动;
onUpdate中只写数值,不做重计算/大遍历。 -
AGV:能按路径依次到达每个点,运动连贯,无明显停顿或跳变。
-
机械臂关节:按设定角度旋转,启停平滑,旋转方向正确。
-
组合:序列与并行两种方式都能运行,且互不干扰(不会“一个动画启动后另一个失效”)。
-
资源:切换路由/卸载组件后不应越来越卡(至少保证渲染循环停止、domElement 移除、renderer dispose)。
建议用下面清单做“最低工程自测”(能快速定位 80% 的卡顿问题):
- 更新闭环是否正确:
- Tween 是否在
requestAnimationFrame中update(time)? - 是否混用了
setInterval推进动画而导致与渲染不同步?
- 帧率是否稳定:
- 是否出现“连续下降”或“周期性抖动”(常见原因:频繁创建对象/频繁分配内存/未释放资源)?
- 每帧逻辑是否过重:
onUpdate中只做“写值”,不要做“查找大量对象/遍历全场景/复杂计算”。
- 动画对象是否可控:
- 是否重复创建大量 Tween 而不结束(例如按钮连点导致堆积)?
- 是否需要在启动前做“禁止重复触发”或“取消上一段动画”的策略(工程扩展点)?
- 资源释放是否到位:
-
卸载时停止
requestAnimationFrame,并renderer.dispose(),移除renderer.domElement。 -
引入 Tween.js 动画库:项目可正常安装依赖并运行。
-
创建 AGV 小车平移动画(指定路径):至少 3 段路径,运动连贯。
-
创建机械臂关节旋转动画:至少 1 个关节,角度正确,启停平滑。
-
调试动画时长、缓动函数:提交一份“参数对比记录”(文字即可:你试了哪些 easing/时长,效果差异是什么,最终选择理由是什么)。
-
实现多个动画的并行或序列执行:提供两个按钮或两种触发方式,分别演示序列与并行。
大模型任务(AI 协同指令模板 + 期望输出 + 校验点)
任务 1:AI 生成 Tween.js 基础动画代码
给 AI 的指令模板:
我在 Vue3 + TypeScript + Three.js 的组件中使用 Tween.js(@tweenjs/tween.js)。请生成一个最小可运行示例:在 requestAnimationFrame 中调用 tweenGroup.update(time),并实现一个 Mesh 从 (0,0,0) 移动到 (2,0,1) 的位移动画。要求代码包含依赖安装命令、Vue SFC 结构、以及每段代码的解释。
期望输出:
npm i @tweenjs/tween.js命令- 一个 Vue SFC 示例(可放在
src/components/) - 动画关键点解释(update、easing、duration、onUpdate 写回)
校验点:
- 是否在
requestAnimationFrame中update(time)? - 是否避免把 Three.js 对象直接作为 Tween state(推荐 state 单独对象)?
任务 2:AI 推荐缓动函数(基于工业设备运动特点)
给 AI 的指令模板:
我需要模拟“电机驱动的 AGV 平移”和“机械臂关节旋转”。请给出两类动作分别推荐的缓动函数(从 Quadratic/Cubic/Quartic/Quintic/Sinusoidal/Exponential 中选择),并解释为什么(启停感、负载感、是否允许瞬时加速度变化)。同时给出推荐的时长范围(例如 0.6s/1.2s/2.0s 对应什么节奏)。
期望输出:
- 每类动作 2–3 个 easing 推荐
- 推荐时长区间与适用场景
校验点:
- 推荐理由是否与“启停/负载/节奏一致性”有关,而不是泛泛描述“更丝滑”?
任务 3:设计 AGV 与机械臂的基础动画方案
给 AI 的指令模板:
期望输出:
- 动作流程(序列版 + 并行版)
- 参数表(可复制)
校验点:
- 是否明确“在渲染循环 update”的必要性?
- 是否能解释“为什么这个 easing/时长更像设备运动”?
课后作业
参考与延伸
- Tween.js:@tweenjs/tween.js(npm 包,核心概念:Tween、Easing、update)
- Three.js:Object3D 变换(position/rotation/scale)与渲染循环(requestAnimationFrame)